// Copyright © 2016 HSL <https://www.hsl.fi>
// This program is dual-licensed under the EUPL v1.2 and AGPLv3 licenses.
package fi.hsl.parkandride.itest;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Lists;
import com.jayway.restassured.response.Response;
import com.jayway.restassured.response.ValidatableResponse;
import fi.hsl.parkandride.back.ContactDao;
import fi.hsl.parkandride.back.FacilityDao;
import fi.hsl.parkandride.back.OperatorDao;
import fi.hsl.parkandride.core.domain.*;
import fi.hsl.parkandride.core.service.ValidationException;
import fi.hsl.parkandride.front.UrlSchema;
import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;
import org.joda.time.format.ISODateTimeFormat;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageNotReadableException;
import javax.inject.Inject;
import java.util.List;
import static com.jayway.restassured.RestAssured.when;
import static fi.hsl.parkandride.core.domain.CapacityType.*;
import static fi.hsl.parkandride.core.domain.DayType.*;
import static fi.hsl.parkandride.core.domain.FacilityStatus.IN_OPERATION;
import static fi.hsl.parkandride.core.domain.PricingMethod.CUSTOM;
import static fi.hsl.parkandride.core.domain.PricingMethod.PARK_AND_RIDE_247_FREE;
import static fi.hsl.parkandride.core.domain.Role.OPERATOR_API;
import static fi.hsl.parkandride.core.domain.Usage.COMMERCIAL;
import static fi.hsl.parkandride.core.domain.Usage.PARK_AND_RIDE;
import static java.util.Arrays.asList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple;
import static org.hamcrest.Matchers.*;
import static org.springframework.http.HttpStatus.*;
public class UtilizationITest extends AbstractIntegrationTest {
private static final int CAR_BUILT_CAPACITY = 1000;
private interface Key {
String FACILITY_ID = "facilityId";
String CAPACITY_TYPE = "capacityType";
String USAGE = "usage";
String TIMESTAMP = "timestamp";
String SPACES_AVAILABLE = "spacesAvailable";
String CAPACITY = "capacity";
}
@Inject ContactDao contactDao;
@Inject FacilityDao facilityDao;
@Inject OperatorDao operatorDao;
private Facility facility;
private Operator operator;
private Contact contact;
private String authToken;
private DateTimeZone originalDateTimeZone;
@Before
public void setTimezone() {
originalDateTimeZone = DateTimeZone.getDefault();
DateTimeZone.setDefault(DateTimeZone.UTC);
}
@After
public void restoreTimezone() {
DateTimeZone.setDefault(originalDateTimeZone);
}
@Before
public void initFixture() {
devHelper.deleteAll();
operator = createOperator(1, "smooth operator");
contact = createContact(1, "minimal contact");
facility = createFacility(1, "minimal facility", operator, contact);
devHelper.createOrUpdateUser(new NewUser(1L, "operator", OPERATOR_API, facility.operatorId, "operator"));
authToken = devHelper.login("operator").token;
}
@Test
public void cannot_update_other_operators_facility() {
Operator operator2 = createOperator(2, "another operator");
Facility facility2 = createFacility(2, "another facility", operator2, contact);
submitUtilization(FORBIDDEN, facility2.id, minValidPayload());
}
@Test
public void accepts_ISO8601_UTC_timestamp() {
submitUtilization(OK, facility.id, minValidPayload().put(Key.TIMESTAMP, "2015-01-01T11:00:00.000Z"));
assertThat(getUtilizationTimestamp()).isEqualTo(new DateTime(2015, 1, 1, 11, 0, 0, DateTimeZone.UTC));
}
@Test
public void accepts_ISO8601_non_UTC_timestamp() {
submitUtilization(OK, facility.id, minValidPayload().put(Key.TIMESTAMP, "2015-01-01T13:00:00.000+02:00"));
assertThat(getUtilizationTimestamp()).isEqualTo(new DateTime(2015, 1, 1, 11, 0, 0, DateTimeZone.UTC));
}
@Test
public void rejects_epoch_timestamps() {
submitUtilization(BAD_REQUEST, facility.id, minValidPayload().put(Key.TIMESTAMP, System.currentTimeMillis() / 1000)); // in seconds
submitUtilization(BAD_REQUEST, facility.id, minValidPayload().put(Key.TIMESTAMP, System.currentTimeMillis())); // in milliseconds
}
@Test
public void returns_timestamps_in_default_timezone() {
DateTimeZone.setDefault(DateTimeZone.forOffsetHours(8));
submitUtilization(OK, facility.id, minValidPayload().put(Key.TIMESTAMP, "2015-01-01T11:00:00.000Z"));
assertThat(getUtilizationTimestampString()).isEqualTo("2015-01-01T19:00:00.000+08:00");
}
@Test
public void accepts_unset_optional_values_with_null_value() {
objectMapper.setSerializationInclusion(JsonInclude.Include.ALWAYS);
multiCapacityCreate();
}
@Test
public void accepts_unset_optional_values_to_be_absent() {
objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
multiCapacityCreate();
}
@Test
public void timestamp_is_required() {
submitUtilization(BAD_REQUEST, facility.id, minValidPayload().put(Key.TIMESTAMP, null))
.spec(assertResponse(ValidationException.class))
.body("violations[0].path", is("[0]." + Key.TIMESTAMP))
.body("violations[0].type", is("NotNull"));
}
@Test
public void timestamp_must_have_timezone() {
submitUtilization(BAD_REQUEST, facility.id, minValidPayload().put(Key.TIMESTAMP, "2015-01-01T11:00:00.000"))
.spec(assertResponse(HttpMessageNotReadableException.class))
.body("violations[0].path", is("[0]." + Key.TIMESTAMP))
.body("violations[0].type", is("TypeMismatch"))
.body("violations[0].message", containsString("expected ISO 8601 date time with timezone"));
}
@Test
public void timestamp_must_have_at_least_second_precision() {
submitUtilization(OK, facility.id, minValidPayload().put(Key.TIMESTAMP, "2015-01-01T11:00:00Z")); // second precision
submitUtilization(BAD_REQUEST, facility.id, minValidPayload().put(Key.TIMESTAMP, "2015-01-01T11:00Z")) // minute precision
.spec(assertResponse(HttpMessageNotReadableException.class))
.body("violations[0].path", is("[0]." + Key.TIMESTAMP))
.body("violations[0].type", is("TypeMismatch"))
.body("violations[0].message", containsString("expected ISO 8601 date time with timezone"));
submitUtilization(BAD_REQUEST, facility.id, minValidPayload().put(Key.TIMESTAMP, "2015-01-01")) // date precision
.spec(assertResponse(HttpMessageNotReadableException.class))
.body("violations[0].path", is("[0]." + Key.TIMESTAMP))
.body("violations[0].type", is("TypeMismatch"))
.body("violations[0].message", containsString("expected ISO 8601 date time with timezone"));
}
@Test
public void timestamp_may_be_a_little_into_the_future() { // in case the server clocks are in different time
submitUtilization(OK, facility.id, minValidPayload().put(Key.TIMESTAMP, DateTime.now().plusMinutes(2)));
}
@Test
public void timestamp_must_not_be_far_into_the_future() {
submitUtilization(BAD_REQUEST, facility.id, minValidPayload().put(Key.TIMESTAMP, DateTime.now().plusMinutes(3)))
.spec(assertResponse(ValidationException.class))
.body("violations[0].path", is("[0]." + Key.TIMESTAMP))
.body("violations[0].type", is("NotFuture"));
}
@Test
public void capacity_type_is_required() {
submitUtilization(BAD_REQUEST, facility.id, minValidPayload().put(Key.CAPACITY_TYPE, null))
.spec(assertResponse(ValidationException.class))
.body("violations[0].path", is("[0]." + Key.CAPACITY_TYPE))
.body("violations[0].type", is("NotNull"));
}
@Test
public void usage_is_required() {
submitUtilization(BAD_REQUEST, facility.id, minValidPayload().put(Key.USAGE, null))
.spec(assertResponse(ValidationException.class))
.body("violations[0].path", is("[0]." + Key.USAGE))
.body("violations[0].type", is("NotNull"));
}
@Test
public void spaces_available_is_required() {
submitUtilization(BAD_REQUEST, facility.id, minValidPayload().put(Key.SPACES_AVAILABLE, null))
.spec(assertResponse(ValidationException.class))
.body("violations[0].path", is("[0]." + Key.SPACES_AVAILABLE))
.body("violations[0].type", is("NotNull"));
}
@Test
public void facility_id_in_playload_is_optional() {
submitUtilization(OK, facility.id, minValidPayload().put(Key.FACILITY_ID, null));
submitUtilization(OK, facility.id, minValidPayload().put(Key.FACILITY_ID, facility.id));
}
@Test
public void facility_id_in_playload_cannot_be_different_from_facility_id_in_path() {
submitUtilization(BAD_REQUEST, facility.id, minValidPayload().put(Key.FACILITY_ID, facility.id + 1))
.spec(assertResponse(ValidationException.class))
.body("violations[0].path", is("[0]." + Key.FACILITY_ID))
.body("violations[0].type", is("NotEqual"));
}
@Test
public void capacity_defaults_to_built_capacity() {
submitUtilization(OK, facility.id, minValidPayload()
.remove(Key.CAPACITY));
Utilization[] utilizations = getFacilityUtilization(facility.id);
assertThat(utilizations).hasSize(1);
assertThat(utilizations[0].capacity).as("capacity").isEqualTo(CAR_BUILT_CAPACITY);
}
@Test
public void capacity_defaults_to_spaces_available_if_no_built_capacity() {
submitUtilization(OK, facility.id, minValidPayload()
.put(Key.CAPACITY_TYPE, MOTORCYCLE.name())
.put(Key.SPACES_AVAILABLE, 666)
.remove(Key.CAPACITY));
assertThat(getFacilityUtilization(facility.id)).isEmpty(); // check that motorcycle doesn't yet have built capacity
facility.builtCapacity = ImmutableMap.of(MOTORCYCLE, 1); // give motorcycle some capacity to make its utilization visible
facilityDao.updateFacility(facility.id, facility);
Utilization[] utilizations = getFacilityUtilization(facility.id);
assertThat(utilizations).hasSize(1);
assertThat(utilizations[0].capacity).as("capacity").isEqualTo(666);
}
@Test
public void updating_capacity_will_NOT_initialize_built_capacity() {
submitUtilization(OK, facility.id, minValidPayload()
.put(Key.CAPACITY_TYPE, MOTORCYCLE.name())
.put(Key.CAPACITY, 100));
FacilityInfo facility = facilityDao.getFacilityInfo(this.facility.id);
assertThat(facility.builtCapacity).doesNotContainKey(MOTORCYCLE);
}
@Test
public void updating_capacity_may_increase_built_capacity() {
submitUtilization(OK, facility.id, minValidPayload()
.put(Key.CAPACITY_TYPE, CAR.name())
.put(Key.CAPACITY, CAR_BUILT_CAPACITY + 50));
FacilityInfo facility = facilityDao.getFacilityInfo(this.facility.id);
assertThat(facility.builtCapacity).containsEntry(CAR, CAR_BUILT_CAPACITY + 50);
}
@Test
public void updating_capacity_will_NOT_decrease_built_capacity() {
submitUtilization(OK, facility.id, minValidPayload()
.put(Key.CAPACITY_TYPE, CAR.name())
.put(Key.CAPACITY, CAR_BUILT_CAPACITY - 50));
FacilityInfo facility = facilityDao.getFacilityInfo(this.facility.id);
assertThat(facility.builtCapacity).containsEntry(CAR, CAR_BUILT_CAPACITY);
}
@Test
public void updating_capacity_may_increase_temporarily_unavailable_spaces() {
submitUtilization(OK, facility.id, minValidPayload()
.put(Key.CAPACITY_TYPE, CAR.name())
.put(Key.CAPACITY, CAR_BUILT_CAPACITY - 50));
Facility facility = facilityDao.getFacility(this.facility.id);
assertThat(facility.unavailableCapacities).contains(new UnavailableCapacity(CAR, PARK_AND_RIDE, 50));
}
@Test
public void updating_capacity_may_decrease_temporarily_unavailable_spaces() {
facility.unavailableCapacities.add(new UnavailableCapacity(CAR, PARK_AND_RIDE, 50));
facilityDao.updateFacility(facility.id, facility);
submitUtilization(OK, facility.id, minValidPayload()
.put(Key.CAPACITY_TYPE, CAR.name())
.put(Key.CAPACITY, CAR_BUILT_CAPACITY - 40));
Facility facility = facilityDao.getFacility(this.facility.id);
assertThat(facility.unavailableCapacities).contains(new UnavailableCapacity(CAR, PARK_AND_RIDE, 40));
}
@Test
public void accepts_spaces_available_which_is_larger_than_capacity() {
submitUtilization(OK, facility.id, minValidPayload()
.put(Key.SPACES_AVAILABLE, CAR_BUILT_CAPACITY + 666)
.put(Key.CAPACITY, CAR_BUILT_CAPACITY));
FacilityInfo facility = facilityDao.getFacilityInfo(this.facility.id);
assertThat(facility.builtCapacity).containsEntry(CAR, CAR_BUILT_CAPACITY);
}
@Test
public void accepts_spaces_available_which_is_larger_than_built_capacity() {
submitUtilization(OK, facility.id, minValidPayload()
.put(Key.SPACES_AVAILABLE, CAR_BUILT_CAPACITY + 666)
.remove(Key.CAPACITY));
FacilityInfo facility = facilityDao.getFacilityInfo(this.facility.id);
assertThat(facility.builtCapacity).containsEntry(CAR, CAR_BUILT_CAPACITY);
}
@Test
public void does_not_show_utilizations_without_built_capacities() {
multiCapacityCreate();
facility.builtCapacity = ImmutableMap.of(CAR, 1000);
facilityDao.updateFacility(facility.id, facility);
Utilization[] results = getFacilityUtilization(facility.id);
assertThat(results)
.extracting("facilityId", "capacityType", "usage", "spacesAvailable")
.containsOnly(tuple(facility.id, CAR, PARK_AND_RIDE, 1));
}
@Test
public void does_not_show_utilizations_without_usages() {
multiCapacityCreate();
facility.builtCapacity = ImmutableMap.of(CAR, 1000);
facility.pricingMethod = CUSTOM;
facility.pricing = asList(
new Pricing(CAR, COMMERCIAL, 1000, BUSINESS_DAY, "00", "24", "0"),
new Pricing(CAR, COMMERCIAL, 1000, SATURDAY, "00", "24", "0"),
new Pricing(CAR, COMMERCIAL, 1000, SUNDAY, "00", "24", "0")
);
facilityDao.updateFacility(facility.id, facility);
Utilization[] results = getFacilityUtilization(facility.id);
assertThat(results).isEmpty();
}
@Test
public void does_not_show_utilizations_when_usage_for_different_capacity_type() {
// CAR & COMMERCIAL should not show up in the list
// ELECTRIC_CAR & PARK_AND_RIDE should not show up in the list
registerUtilizations(asList(
utilize(CAR, PARK_AND_RIDE),
utilize(ELECTRIC_CAR, PARK_AND_RIDE),
utilize(CAR, COMMERCIAL),
utilize(ELECTRIC_CAR, COMMERCIAL)
));
facility.pricing = asList(
new Pricing(CAR, PARK_AND_RIDE, 100, BUSINESS_DAY, "00", "24", "0"),
new Pricing(CAR, PARK_AND_RIDE, 100, SATURDAY, "00", "24", "0"),
new Pricing(CAR, PARK_AND_RIDE, 100, SUNDAY, "00", "24", "0"),
new Pricing(ELECTRIC_CAR, COMMERCIAL, 50, BUSINESS_DAY, "00", "24", "0"),
new Pricing(ELECTRIC_CAR, COMMERCIAL, 50, SATURDAY, "00", "24", "0"),
new Pricing(ELECTRIC_CAR, COMMERCIAL, 50, SUNDAY, "00", "24", "0")
);
facility.pricingMethod = CUSTOM;
facilityDao.updateFacility(facility.id, facility);
final Utilization[] results = getFacilityUtilization(facility.id);
assertThat(results)
.extracting(u -> tuple(u.capacityType, u.usage))
.hasSize(2)
.contains(
tuple(CAR, PARK_AND_RIDE),
tuple(ELECTRIC_CAR, COMMERCIAL)
);
}
@Test
public void lists_latest_utilization_for_all_facilities() {
Facility facility2 = createFacility(2, "another facility", operator, contact);
DateTime t1 = DateTime.now().minusHours(1);
DateTime t2 = t1.plusMinutes(1);
DateTime t3 = t1.plusMinutes(2);
DateTime t4 = t1.plusMinutes(3);
submitUtilization(OK, facility.id, minValidPayload()
.put(Key.TIMESTAMP, t1)
.put(Key.SPACES_AVAILABLE, 5));
submitUtilization(OK, facility.id, minValidPayload()
.put(Key.TIMESTAMP, t2)
.put(Key.SPACES_AVAILABLE, 15));
submitUtilization(OK, facility2.id, minValidPayload()
.put(Key.TIMESTAMP, t3)
.put(Key.SPACES_AVAILABLE, 25));
submitUtilization(OK, facility2.id, minValidPayload()
.put(Key.TIMESTAMP, t4)
.put(Key.SPACES_AVAILABLE, 35));
Utilization u2 = new Utilization();
u2.facilityId = facility.id;
u2.timestamp = t2;
u2.spacesAvailable = 15;
u2.capacityType = CAR;
u2.usage = PARK_AND_RIDE;
u2.capacity = CAR_BUILT_CAPACITY;
Utilization u4 = new Utilization();
u4.facilityId = facility2.id;
u4.timestamp = t4;
u4.spacesAvailable = 35;
u4.capacityType = CAR;
u4.usage = PARK_AND_RIDE;
u4.capacity = CAR_BUILT_CAPACITY;
assertThat(getUtilizations()).containsOnly(u2, u4);
}
// helpers
private static JSONObjectBuilder minValidPayload() {
return new JSONObjectBuilder()
.put(Key.CAPACITY_TYPE, CapacityType.CAR)
.put(Key.USAGE, Usage.PARK_AND_RIDE)
.put(Key.TIMESTAMP, DateTime.now())
.put(Key.SPACES_AVAILABLE, 42);
}
private ValidatableResponse submitUtilization(HttpStatus expectedStatus, Long facilityId, JSONObjectBuilder builder) {
return givenWithContent(authToken).body(builder.asArray())
.when().put(UrlSchema.FACILITY_UTILIZATION, facilityId)
.then().statusCode(expectedStatus.value());
}
private DateTime getUtilizationTimestamp() {
return ISODateTimeFormat.dateTimeParser().parseDateTime(getUtilizationTimestampString());
}
private String getUtilizationTimestampString() {
Response response = when().get(UrlSchema.FACILITY_UTILIZATION, facility.id);
response.then().assertThat().body(".", hasSize(1));
return response.body().jsonPath().getString("[0].timestamp");
}
private void multiCapacityCreate() {
multiCapacityCreate(Usage.PARK_AND_RIDE, Usage.PARK_AND_RIDE, Usage.PARK_AND_RIDE);
}
private void multiCapacityCreate(Usage usageCar, Usage usageBike, Usage usageElectric) {
DateTime now = DateTime.now();
Utilization u1 = new Utilization();
u1.timestamp = now;
u1.spacesAvailable = 1;
u1.capacityType = CapacityType.CAR;
u1.usage = usageCar;
Utilization u2 = new Utilization();
u2.timestamp = now.minusSeconds(10);
u2.spacesAvailable = 1;
u2.capacityType = CapacityType.BICYCLE;
u2.usage = usageBike;
Utilization u3 = new Utilization();
u3.timestamp = now.minusSeconds(20);
u3.spacesAvailable = 2;
u3.capacityType = CapacityType.ELECTRIC_CAR;
u3.usage = usageElectric;
List<Utilization> payload = Lists.newArrayList(u1, u2, u3);
registerUtilizations(payload);
Utilization[] results = getFacilityUtilization(facility.id);
assertThat(results)
.extracting("facilityId", "capacityType", "usage", "spacesAvailable", "timestamp")
.contains(
tuple(facility.id, u1.capacityType, u1.usage, u1.spacesAvailable, u1.timestamp.toInstant()),
tuple(facility.id, u2.capacityType, u2.usage, u2.spacesAvailable, u2.timestamp.toInstant()),
tuple(facility.id, u3.capacityType, u3.usage, u3.spacesAvailable, u3.timestamp.toInstant()));
}
private Utilization[] getFacilityUtilization(long facilityId) {
return when().get(UrlSchema.FACILITY_UTILIZATION, facilityId)
.then().statusCode(OK.value())
.extract().as(Utilization[].class);
}
private Utilization[] getUtilizations() {
return when().get(UrlSchema.UTILIZATIONS)
.then().statusCode(OK.value())
.extract().as(Utilization[].class);
}
private Utilization utilize(CapacityType capacityType, Usage usage) {
final Utilization utilization = new Utilization();
utilization.facilityId = facility.id;
utilization.capacityType = capacityType;
utilization.spacesAvailable = 50;
utilization.usage = usage;
utilization.timestamp = DateTime.now().minusSeconds(10);
return utilization;
}
private void registerUtilizations(List<Utilization> payload) {
givenWithContent(authToken)
.body(payload)
.when()
.put(UrlSchema.FACILITY_UTILIZATION, facility.id)
.then()
.statusCode(OK.value());
}
public Facility createFacility(long id, String name, Operator operator, Contact contact) {
Facility f = new Facility();
f.id = id;
f.status = IN_OPERATION;
f.pricingMethod = PARK_AND_RIDE_247_FREE;
f.name = new MultilingualString(name);
f.operatorId = operator.id;
f.location = Spatial.fromWktPolygon("POLYGON((25.010822 60.25054, 25.010822 60.250023, 25.012479 60.250337, 25.011449 60.250885, 25.010822 60.25054))");
f.contacts = new FacilityContacts(contact.id, contact.id);
f.builtCapacity = ImmutableMap.of(
CAR, CAR_BUILT_CAPACITY,
BICYCLE, 100,
ELECTRIC_CAR, 10
);
facilityDao.insertFacility(f, f.id);
return f;
}
public Contact createContact(long id, String name) {
Contact c = new Contact();
c.id = id;
c.name = new MultilingualString(name);
contactDao.insertContact(c, c.id);
return c;
}
public Operator createOperator(long id, String name) {
Operator o = new Operator();
o.id = id;
o.name = new MultilingualString(name);
operatorDao.insertOperator(o, o.id);
return o;
}
}